<?php

declare(strict_types = 1);

// {{{ License
// This file is part of GNU social - https://www.gnu.org/software/social
//
// GNU social is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// GNU social is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with GNU social.  If not, see <http://www.gnu.org/licenses/>.
// }}}

namespace Component\Notification;

use App\Core\DB;
use App\Core\Event;
use function App\Core\I18n\_m;
use App\Core\Log;
use App\Core\Modules\Component;
use App\Core\Queue;
use App\Core\Router;
use App\Entity\Activity;
use App\Entity\Actor;
use App\Entity\LocalUser;
use App\Util\Exception\ServerException;
use Component\FreeNetwork\FreeNetwork;
use Component\Notification\Controller\Feed;
use EventResult;
use Exception;
use Throwable;

class Notification extends Component
{
    public function onAddRoute(Router $m): EventResult
    {
        $m->connect('feed_notifications', '/feed/notifications', [Feed::class, 'notifications']);
        return Event::next;
    }

    /**
     * @throws ServerException
     */
    public function onCreateDefaultFeeds(int $actor_id, LocalUser $user, int &$ordering): EventResult
    {
        DB::persist(\App\Entity\Feed::create([
            'actor_id' => $actor_id,
            'url'      => Router::url($route = 'feed_notifications'),
            'route'    => $route,
            'title'    => _m('Notifications'),
            'ordering' => $ordering++,
        ]));
        return Event::next;
    }

    /**
     * Enqueues a notification for an Actor (such as person or group) which means
     * it shows up in their home feed and such.
     * WARNING: It's highly advisable to have flushed any relevant objects before triggering this event.
     *
     * $targets should be of the shape:
     * (int|Actor)[] // Prefer Actor whenever possible
     * Example of $targets:
     * [42, $actor_alice, $actor_bob] // Avoid repeating actors or ids
     *
     * @param Actor                      $sender   The one responsible for this activity, take care not to include it in targets
     * @param Activity                   $activity The activity responsible for the object being given to known to targets
     * @param non-empty-array<Actor|int> $targets  Attentions, Mentions, any other source. Should never be empty, you usually want to register an attention to every $sender->getSubscribers()
     * @param null|string                $reason   An optional reason explaining why this notification exists
     */
    public function onNewNotification(Actor $sender, Activity $activity, array $targets, ?string $reason = null): EventResult
    {
        // Ensure targets are all actor objects and unique
        $effective_targets = [];
        foreach ($targets as $target) {
            if (\is_int($target)) {
                $target_id     = $target;
                $target_object = null;
            } else {
                $target_id     = $target->getId();
                $target_object = $target;
            }
            if (!\array_key_exists(key: $target_id, array: $effective_targets)) {
                $target_object ??= Actor::getById($target_id);
                $effective_targets[$target_id] = $target_object;
            }
        }
        unset($targets);

        if (Event::handle('NewNotificationStart', [$sender, $activity, $effective_targets, $reason]) === Event::next) {
            self::notify($sender, $activity, $effective_targets, $reason);
        }

        Event::handle('NewNotificationEnd', [$sender, $activity, $effective_targets, $reason]);
        return Event::next;
    }

    /**
     * @param mixed[] $retry_args
     */
    public function onQueueNotificationLocal(Actor $sender, Activity $activity, Actor $target, ?string $reason, array &$retry_args): EventResult
    {
        // TODO: use https://symfony.com/doc/current/notifier.html
        return Event::stop;
    }

    /**
     * @param Actor[] $targets
     * @param mixed[] $retry_args
     */
    public function onQueueNotificationRemote(Actor $sender, Activity $activity, array $targets, ?string $reason, array &$retry_args): EventResult
    {
        if (FreeNetwork::notify($sender, $activity, $targets, $reason)) {
            return Event::stop;
        } else {
            return Event::next;
        }
    }

    /**
     * Bring given Activity to Targets' knowledge.
     * This will flush a Notification to DB.
     *
     * @param Actor[] $targets
     *
     * @return bool true if successful, false otherwise
     */
    public static function notify(Actor $sender, Activity $activity, array $targets, ?string $reason = null): bool
    {
        $remote_targets = [];
        foreach ($targets as $target) {
            if ($target->getIsLocal()) {
                if ($target->hasBlocked($author = $activity->getActor())) {
                    Log::info("Not saving notification to actor {$target->getId()} from sender {$sender->getId()} because receiver blocked author {$author->getId()}.");
                    continue;
                }
                if (Event::handle('NewNotificationShould', [$activity, $target]) === Event::next) {
                    if ($sender->getId()           === $target->getId()
                        || $activity->getActorId() === $target->getId()) {
                        // The target already knows about this, no need to bother with a notification
                        continue;
                    }
                }
                Queue::enqueue(
                    payload: [$sender, $activity, $target, $reason],
                    queue: 'NotificationLocal',
                    priority: true,
                );
            } else {
                // We have no authority nor responsibility of notifying remote actors of a remote actor's doing
                if ($sender->getIsLocal()) {
                    $remote_targets[] = $target;
                }
            }
            // XXX: Unideal as in failures the rollback will leave behind a false notification,
            // but most notifications (all) require flushing the objects first
            // Should be okay as long as implementations bear this in mind
            try {
                DB::wrapInTransaction(fn () => DB::persist(Entity\Notification::create([
                    'activity_id' => $activity->getId(),
                    'target_id'   => $target->getId(),
                    'reason'      => $reason,
                ])));
            } catch (Exception|Throwable $e) {
                // We do our best not to record duplicate notifications, but it's not insane that can happen
                Log::error('It was attempted to record an invalid notification!', [$e]);
            }
        }

        if ($remote_targets !== []) {
            Queue::enqueue(
                payload: [$sender, $activity, $remote_targets, $reason],
                queue: 'NotificationRemote',
                priority: false,
            );
        }

        return true;
    }
}
